Skip to content

fix(tokens): publish token events only after commit - #2245

Open
HayimShaul wants to merge 1 commit into
mainfrom
2183_publish_token_events_after_transaction_commit
Open

fix(tokens): publish token events only after commit#2245
HayimShaul wants to merge 1 commit into
mainfrom
2183_publish_token_events_after_transaction_commit

Conversation

@HayimShaul

Copy link
Copy Markdown
Contributor

Fixes #2183

AddToken / DeleteToken events were published from inside the still-open database transaction that produced them, so a subscriber could act on a token that was later rolled back and never persisted.

The events are now buffered on DBTransaction and published only once the change is durable:

  • DBTransaction.Commit(ctx) publishes them, and only if the underlying commit succeeded — this covers the transactions the service owns.
  • DBTransaction.Rollback discards them.
  • DBTransaction.FlushEvents(ctx) publishes them explicitly, for transactions owned by the caller.

Service.AppendValid applies a request to a transaction it does not own, so it now returns a PostCommit function (never nil, idempotent) that the owner invokes once its own commit succeeded. The finality listener calls it after tx.Commit() and before NotifyStatus, so a woken finality waiter never sees a confirmed transaction whose token events are still pending.

Tests cover both ownership models: nothing published before commit, nothing published on rollback or on a failed commit, events published in recording order after commit, and the listener ordering commit → publish → notify-status. docs/services/tokens.md documents the new contract.

@HayimShaul HayimShaul added this to the Q3/26 milestone Aug 17, 2026
@HayimShaul HayimShaul added the bug Something isn't working label Aug 17, 2026
@HayimShaul HayimShaul self-assigned this Aug 17, 2026
@HayimShaul
HayimShaul marked this pull request as ready for review August 18, 2026 08:59
Token add and delete events were published while the database transaction
that produced them was still open, so a subscriber could observe a token
that was later rolled back and never persisted.

Buffer the events on DBTransaction and publish them from Commit, once the
underlying commit succeeded, or from FlushEvents when the transaction is
owned by the caller. Rollback discards them.

Service.AppendValid applies a request to a transaction it does not own, so
it now returns a PostCommit function that the owner of the transaction
invokes once its commit succeeded. The finality listener calls it after
committing and before waking the finality waiters, so a confirmed
transaction is never observed with its token events still pending.

Signed-off-by: Hayim.Shaul@ibm.com <hayimsha@fhe03.vpc.cloud9.ibm.com>
@HayimShaul
HayimShaul force-pushed the 2183_publish_token_events_after_transaction_commit branch from 6791660 to dc383fd Compare August 18, 2026 09:32

@AkramBitar AkramBitar left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fix is real and correct — I verified it rather than taking the description at face value. On the merge-base I wired tokens.Service with fakes and called AppendValid directly: it published 2 events with CommitCallCount() == 0, so events really did escape from inside a still-open transaction. The new code buffers them and flushes after tx.Commit(), in the right order (commit → publish → notify-status).

Also checked: go build ./... (root + integration), go vet ./..., gofmt -l on the touched packages, and go test plus go test -race on ./token/services/tokens/... and ./token/services/ttx/finality/... — all clean. go generate ./... in token/services/ttx/finality reproduces mock/tokens_service.go byte-identically. AppendValid has exactly one non-test caller (finality.Commit, shared by the listener and the recovery handler) and AppendToken/DeleteToken are the only event producers, so no path is left un-flushed.

No defects in the buffering mechanism itself: Notify's Notifier == nil guard means pending can never be non-empty with a nil Notifier, so FlushEvents' unguarded Publish cannot nil-deref; Rollback clears before delegating (the safe direction if the underlying rollback fails); err at listener.go:212 is reassigned rather than shadowed, so the named-return rollback defer in AppendValid still fires on every error path; PostCommit is a type alias, so *tokens.Service still satisfies the finality tokensService interface; and double-publish is covered by FlushEvents draining the buffer.

Two minor findings below, both low severity — neither blocks merge.

return errors.WithMessagef(err, "transaction [%s], failed to check existence in db", txID)
return noPostCommit, errors.WithMessagef(err, "transaction [%s], failed to check existence in db", txID)
}
if exists {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low — worth writing down rather than changing. This fix converts token-event delivery from at-least-once to at-most-once, and the TransactionExists short-circuit makes the loss permanent. The PR description does not mention the trade-off.

Concretely: finality.Commit calls tx.Commit(); the server commits but the client sees an error (connection reset, driver timeout on the commit ack), or the process dies between tx.Commit() returning and publishTokenEvents(ctx) at listener.go:239. The tokens are durably in the store but no event was published. On the retryRunner retry — or on the next pass of the recovery handler (recovery.go:125) — AppendValid finds exists == true and returns noPostCommit, so the store-token/delete-token events for that transaction are never emitted.

For the one in-repo subscriber (certifier/interactive) this self-heals only at the next process start via Scan() (driver.go:172); a third-party subscriber loses the notification silently.

The previous behaviour was worse — events for transactions that never committed — so this is still the right direction. But it belongs in the contract section of docs/services/tokens.md, or the recovery path should re-emit events for an already-applied transaction.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low — test-only, but a silent coverage regression on exactly the code this PR rewrites.

Three pre-existing negative assertions became vacuous, because they assert pub.PublishCallCount() == 0 on a transaction that is never committed — and events are now buffered until commit, so they would pass no matter what the code under test does:

  • TestTransaction_AppendToken:64 (no owners → no event)
  • TestTransaction_AppendToken_NoNotify:276 (empty owner ID → no event)
  • TestTransaction_DeleteToken_AbsentTokenIsNotAnError:355 (token absent locally → no event)

Verified by mutation: deleting the if len(id) == 0 { continue } guard in AppendToken (storage.go:235) makes the base suite fail (TestTransaction_AppendToken_NoNotify: got 1, want 0) but leaves this PR's suite fully green.

The PR correctly added tx.Commit(ctx) to the two positive tests and to TestTransaction_Notify_NoPublisher, just not to these three. require.NoError(t, tx.Commit(ctx)) before each assertion restores the property each comment claims to protect.

(Anchored at file level because these lines are outside the diff hunks.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

tokens: AddToken/DeleteToken events are published inside the open transaction, so subscribers observe tokens that are never committed

2 participants